Skip to content

Compound Components

Every now and then, you might encounter a component library or other third-party package that does something a bit strange:

// Example from React Bootstrap
import Dropdown from 'react-bootstrap/Dropdown';
function UserButton() {
return (
<Dropdown>
<Dropdown.Toggle>
Actions
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item href="/change-email">
Change Email
</Dropdown.Item>
<Dropdown.Item href="/reset-pwd">
Reset Password
</Dropdown.Item>
<Dropdown.Item href="/delete">
Delete account
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}

If you're not familiar with this pattern, it can be pretty bewildering. Why do some elements have dots in the names?? And where are they coming from?

This pattern is called compound components. And honestly, I'm not a big fan.

I wasn't planning on covering this pattern at all, but frustratingly, it's used by quite a few component libraries and other third-party packages. And so, in this lesson, we'll unpack what's going on, to help you understand this pattern when you see it in the wild. I'll also share an alterative way to achieve the same goals.

The big trick

So, here's a fun JavaScript quirk: functions can have properties.

function addNums(a, b) {
return a + b;
}
addNums.hello = "world";
console.log(addNums.hello); // "world"

In JavaScript, functions are secretly objects. This means we can assign properties to them, the same way we read/write the properties on an object.

This means we can attach one component to another, using this same notation:

function Dropdown({ children }) {
return (
<div>{children}</div>
);
}
function DropdownToggle({ children }) {
return (
<button>{children}</button>
);
}
function DropdownMenu({ children }) {
return (
<nav>{children}</nav>
);
}
function DropdownItem({ href, children }) {
return (
<a href={href}>{children}</a>
);
}
Dropdown.Toggle = DropdownToggle;
Dropdown.Menu = DropdownMenu;
Dropdown.Item = DropdownItem;
export default Dropdown;

That's how this pattern works, from a syntax perspective. We “hang” a bunch of smaller components on one main component.

This is one of those situations where the JSX can obfuscate things a bit, to make them seem more magical than they really are. Let's consider how these compound components get compiled:

// in JSX:
<Dropdown.Item href="/change-email">
Change Email
</Dropdown.Item>
// Compiled to JS:
React.createElement(
Dropdown.Item,
{ href: '/change-email' },
'Change Email'
)

We're accessing the Item property on the Dropdown function, which resolves to the DropdownItem component we defined above.

An alternative structure

Let's look at another way we could structure things:

function Dropdown({ children }) {
return (
<div>{children}</div>
);
}
export function DropdownToggle({ children }) {
return (
<button>{children}</button>
);
}
export function DropdownMenu({ children }) {
return (
<nav>{children}</nav>
);
}
export function DropdownItem({ href, children }) {
return (
<a href={href}>{children}</a>
);
}
export default Dropdown;

Instead of hanging all of our secondary components on the Dropdown function, we're exporting them as named exports.

Then, on the consumer side, we can import and render them:

import Dropdown, {
DropdownToggle,
DropdownMenu,
DropdownItem,
} from 'hypothetical-react-bootstrap/Dropdown';
function UserButton() {
return (
<Dropdown>
<DropdownToggle>
Actions
</DropdownToggle>
<DropdownMenu>
<DropdownItem href="/change-email">
Change Email
</DropdownItem>
<DropdownItem href="/reset-pwd">
Reset Password
</DropdownItem>
<DropdownItem href="/delete">
Delete account
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
}

I think the reason that libraries tend to prefer the compound component pattern is because it simplifies the import statement. We only need to specify a single Dropdown component import and we get all the secondary components for free! No need to list them out one-by-one.

But that's a pretty small benefit when we consider the costs:

  • It's confusing! When I taught React at a local coding bootcamp, a surprising number of students would ask questions about this strange syntax they saw in third-party libraries.
  • We lose access to bundler optimizations like .
  • There can be compatibility issues, like with Next 13 (described below).

The import/export keywords are the main mechanism we have in JS to share code between modules. The whole ecosystem is built on the assumption that this is how we do things!

If we're going to deviate from the standard way of doing things, there had better be some really compelling advantages to make it worth it. In my opinion, trimming down the number of imports doesn't come close to balancing the scales.

DIY API design

In this lesson, we've been focusing on the syntax of compound components, to understand what the heck <Dropdown.Item> is.

Syntax aside, there's kind of a cool idea here!

Sticking with the “Dropdown” example, let's consider two different possible APIs for this component:

// Version 1: A closed system
<Dropdown
label="Actions"
options={[
{ href: '/change-email', label: "Change Email" },
{ href: '/reset-pwd', label: "Reset Password" },
{ href: '/delete', label: "Delete Account" },
]}
/>
// Version 2: DIY sub-components
<Dropdown>
<DropdownToggle>
Actions
</DropdownToggle>
<DropdownMenu>
<DropdownItem href="/change-email">
Change Email
</DropdownItem>
<DropdownItem href="/reset-pwd">
Reset Password
</DropdownItem>
<DropdownItem href="/delete">
Delete account
</DropdownItem>
</DropdownMenu>
</Dropdown>

In Version 1, the Dropdown component is a black box. We give it the raw data, and it produces a chunk of UI for us.

In Version 2, however, the Dropdown component is more like a piece of furniture from IKEA. We're given all of the parts, and we have to assemble it ourselves.

Ultimately, both approaches have their use cases. There's no right/wrong answer here, just different tradeoffs:

  • Version 1 is easier to use, but rigid. We can't customize it at all.
  • Version 2 is a bit more work to set up, but a lot more flexible. We can combine the pieces in different ways, or even substitute in our own sub-components!

In practice, this “DIY” approach is most often used with third-party component libraries. These tools need to be flexible, since they'll be used in all sorts of different applications.

Flexibility isn't always a good thing. We don't necessarily want the components we create to be so customizable! We'll talk about this more in the next lesson.

I should also warn you, producing components using this sort of “DIY” API is quite tricky. It's a can of worms we won't be opening in this course. But hopefully this lesson has helped prepare you for libraries that use this sort of design, and down the line you can explore building components using this kind of structure!